%run imports.py
NOT_A_CARD = -1
DECK_SIZE = 30
MAX_CARDS_IN_HAND = 10
MAX_CARD_MANA_COST = 10
MAX_MANA = 10
STARTING_CARDS_NO_COIN = 3
STARTING_CARDS_WITH_COIN = 4
def hat_to_deck(hat):
"""Converts a hat into a deck, represented as a mana curve.
The `hat` encodes a mana curve as a piecewise-linear function.
For example:
1-drops, a-drops, b-drops, 10-drops, a, b = hat
Examples:
>>> deck = hat_to_deck([1, 8, 8, 1, 2, 4])
"""
# Extract the mana curve heights and locations from `hat`.
hat = np.array(hat)
n = (len(hat) + 2) // 2
hat_height, hat_loc = hat[:n], hat[-(n - 2):]
idx = np.argsort(hat_loc)
hat_height[1:-1], hat_loc = hat_height[idx + 1], hat_loc[idx]
# Compute the mana curve by evaluating the piecewise-linear function.
deck = np.zeros(MAX_CARD_MANA_COST + 1)
deck[1:] = np.interp(
np.arange(MAX_CARD_MANA_COST) + 1,
[1] + list(hat_loc) + [MAX_CARD_MANA_COST],
hat_height)
# Return a default deck if input is bad.
if np.sum(deck) <= 0:
deck = 3 * np.ones(MAX_CARD_MANA_COST + 1)
deck[0] = 0
return deck
# Round the fractional counts and make sure it sums to DECK_SIZE.
deck = deck / np.sum(deck) * DECK_SIZE
deck_rounded = np.round(deck)
delta = np.sum(deck_rounded) - DECK_SIZE
sign = np.sign(delta)
deck_rounded[np.argsort(
sign * (deck - deck_rounded))[:int(sign * delta)]] -= sign
assert np.sum(deck_rounded) == DECK_SIZE
return deck_rounded.astype(np.int32)
def rand_deck():
"""Generates a random deck from a random hat."""
rand_hat = [
np.random.uniform(low=0., high=12.),
12.,
np.random.uniform(low=0., high=12.),
np.random.uniform(low=1., high=MAX_CARD_MANA_COST)]
return hat_to_deck(rand_hat)
deck = rand_deck()
assert np.sum(deck) == DECK_SIZE
print('Deck: {deck}'.format(deck=deck))
Deck: [0 3 3 3 3 3 3 4 3 3 2]
def empty_hand(max_cards_in_hand=MAX_CARDS_IN_HAND):
"""Returns an empty hand."""
hand = np.empty(max_cards_in_hand, dtype=np.int32)
hand[:] = NOT_A_CARD
return hand
def draw_card(deck, hand):
"""Draws a card from a deck and places it in the hand."""
card_cost = np.random.choice(
np.arange(len(deck)),
p=(deck / np.sum(deck)))
empty_slots = hand == NOT_A_CARD
if np.any(empty_slots):
hand[np.argmax(empty_slots)] = card_cost
deck[card_cost] -= 1
def draw_hand(deck, coin):
"""Draws the cards for the mulligan phase."""
hand = empty_hand()
for i in range(STARTING_CARDS_WITH_COIN if coin else STARTING_CARDS_NO_COIN):
draw_card(deck, hand)
return hand
deck = rand_deck()
hand = draw_hand(deck, coin=True)
assert np.sum(deck) == DECK_SIZE - STARTING_CARDS_WITH_COIN
assert np.sum(hand != NOT_A_CARD) == STARTING_CARDS_WITH_COIN
print('Deck: {deck}\nHand: {hand}'.format(deck=deck, hand=hand[hand != NOT_A_CARD]))
Deck: [0 0 1 1 3 2 3 5 4 6 1] Hand: [5 8 3 6]
def shuffle_cards_from_hand_in_deck(deck, hand, cards):
"""Shuffles selected cards from the hand into the deck."""
card_costs = hand[cards]
card_cost_counts = np.bincount(card_costs)
hand[cards] = -1
deck[:len(card_cost_counts)] += card_cost_counts
def mulligan(deck, hand):
"""Optimally mulligans cards in hand back to the deck.
The logic applied is as follows:
1. Mark cards with distinct costs that are among the cheapest we can
get from this deck as cards we want to keep.
2. If we don't have the coin, remove all copies of cards with the same
cost (even among the marked cards). If we do have the coin, remove
all copies except for the cheapest double among the marked cards,
if any.
3. Remove cards that have a probability greater than 50% of coming
back as a card with a mana cost less than or equal to the current
cost (and is additionally distinct from the marked cards if we
don't have the coin or already have a cheap double).
"""
# Keep cards that are among the cheapest distinct cards we can get.
coin = np.sum(hand != NOT_A_CARD) == STARTING_CARDS_WITH_COIN
num_cards = STARTING_CARDS_WITH_COIN if coin else STARTING_CARDS_NO_COIN
cheapest_card_costs = 1 + np.where(deck[1:] > 0)[0][:num_cards]
keep_cheap = np.in1d(hand, cheapest_card_costs)
# Without the coin, remove doubles.
# With the coin, remove all but the cheapest double.
unique_cheap, unique_idx = np.unique(hand[keep_cheap], return_index=True)
hand_already_has_cheap_double = len(unique_cheap) < len(hand[keep_cheap])
is_unique_cheap = np.zeros(len(keep_cheap), dtype=bool)
is_unique_cheap[np.where(keep_cheap)[0][unique_idx]] = True
remove_doubles = keep_cheap & (~is_unique_cheap)
if coin and np.any(remove_doubles):
cheapest_double = np.argmin(hand[remove_doubles])
remove_doubles[np.where(remove_doubles)[0][cheapest_double]] = False
keep_cheap = keep_cheap & (~remove_doubles)
# Remove cards that have a higher chance of returning as a cheaper card
# while having a mana cost different from the ones we're keeping.
pruned_deck = np.copy(deck)
if not coin or hand_already_has_cheap_double:
pruned_deck[np.append(0, unique_cheap)] = 0
prob_cheaper_card = np.cumsum(pruned_deck)
prob_cheaper_card = prob_cheaper_card / max(1, np.max(prob_cheaper_card))
remove_gamble = (prob_cheaper_card[hand] > 0.5) & (hand != NOT_A_CARD) & (~keep_cheap)
# Replace cards.
remove = remove_doubles | remove_gamble
shuffle_cards_from_hand_in_deck(deck, hand, remove)
for i in range(np.sum(remove)):
draw_card(deck, hand)
deck = np.array([0, 2, 6, 4, 4, 4, 4, 0, 2, 2, 2])
hand = draw_hand(deck, coin=True)
print('Deck before mulligan: {deck}\nHand before mulligan: {hand}'.format(deck=deck, hand=hand[hand != NOT_A_CARD]))
mulligan(deck, hand)
print('Deck after mulligan: {deck}\nHand after mulligan: {hand}'.format(deck=deck, hand=hand[hand != NOT_A_CARD]))
assert np.sum(deck) + np.sum(hand != NOT_A_CARD) == DECK_SIZE
Deck before mulligan: [0 2 6 4 3 4 3 0 1 2 1] Hand before mulligan: [ 4 6 10 8] Deck after mulligan: [0 2 5 3 3 4 4 0 1 2 2] Hand after mulligan: [4 2 8 3]
def sum_constrained_perms(items, max_sum, current_sum=0, current_perm=None):
"""All subsets of items whose sum is no larger than `max_sum`."""
is_root = current_perm is None
if is_root:
current_perm = np.empty(len(items), dtype=np.int32)
current_perm[:] = -1
legal_items = current_sum + items <= max_sum
legal_items[items == NOT_A_CARD] = False
legal_items[current_perm[current_perm != -1]] = False
legal_items[np.arange(len(legal_items))
< np.max(current_perm)] = False
legal_items = np.where(legal_items)[0]
costs, perms = [max_sum - current_sum], [current_perm]
next_index = np.argmin(current_perm)
for index in legal_items:
cs = current_sum + items[index]
cp = np.copy(current_perm)
cp[next_index] = index
c, p = sum_constrained_perms(
items, max_sum, current_sum=cs, current_perm=cp)
costs += c
perms += p
if is_root:
costs, perms = np.array(costs), np.vstack(perms)
return costs, perms
def plan_play(hand, mana, coin):
"""Play this turn by optimally planning this and next turn.
The logic applied is as follows:
1. Compute all plays we can make this turn with the mana we have
available.
2. Keep only the candidate plays that waste the minimal amount of mana
this turn.
3. For each of those candidate plays, compute the possible follow-up
plays we can make next turn.
4. Choose the best play this turn as the candidate play for which we
have the most number of plays next turn that waste the minimum
amount of mana that turn.
5. If we have the coin, repeat steps 1 to 4 with an additional mana
this turn. If the sum of the wasted mana of this and next turn is
less than that of the best play, or if it is equal but we have more
plays this and next turn with the coin, use the coined best play.
"""
def plan_two_turns(hand, mana):
waste, plays = sum_constrained_perms(items=hand, max_sum=mana[0])
min_waste = np.min(waste)
best_plays = plays[waste == min_waste, :]
next_waste, next_plays = [], []
for best_play in best_plays:
next_hand = np.copy(hand)
next_hand[best_play[best_play != -1]] = NOT_A_CARD
w, p = sum_constrained_perms(items=next_hand, max_sum=mana[1])
next_waste += [w]
next_plays += [p]
# Find the optimal play as the one that leaves us with the largest
# number of mana efficient plays the next turn.
next_min_waste = np.min([np.min(nw) for nw in next_waste])
optimal_play = np.argmax([np.sum(nw == next_min_waste) for nw in next_waste])
optimal_play = best_plays[optimal_play, :]
# Compute the total mana wasted and number of plays.
next_waste, next_plays = np.hstack(next_waste), np.vstack(next_plays)
next_best_plays = next_plays[next_waste == next_min_waste, :]
total_waste = min_waste + next_min_waste
total_plays = len(best_plays) * len(next_best_plays)
return optimal_play, min_waste, total_waste, total_plays
play, waste, wastes, plays = plan_two_turns(hand, [mana, min(MAX_MANA, mana + 1)])
used_coin = False
if coin:
play_coin, waste_coin, wastes_coin, plays_coin = plan_two_turns(hand, [mana + 1, min(MAX_MANA, mana + 1)])
if (wastes_coin < wastes) or ((wastes_coin == wastes) and (plays_coin > plays)):
play, waste, plays = play_coin, waste_coin, plays_coin
used_coin = True
return play, waste, plays, used_coin
turn = 5
hand = np.array([4, 2, 2, 4])
optimal_play, mana_wasted, num_plays, used_coin = plan_play(hand, mana=min(MAX_MANA, turn), coin=True)
assert np.sum(sum_constrained_perms(np.arange(10) + 1, max_sum=6)[0] == 0) == 4
print('Hand: {hand}\nTurn: {turn}\nOptimal play: {optimal_play}\nMana wasted: {mana_wasted}\nNumber of mana-optimal plays: {num_plays}\nUsed the coin: {used_coin}'.format(
hand=hand[hand != NOT_A_CARD],
turn=turn,
optimal_play=hand[optimal_play[optimal_play != NOT_A_CARD]],
mana_wasted=mana_wasted,
num_plays=num_plays,
used_coin=used_coin))
Hand: [4 2 2 4] Turn: 5 Optimal play: [4 2] Mana wasted: 0 Number of mana-optimal plays: 16 Used the coin: True
def fetch_empirical_game_turns(dataset='2017-07'):
if not os.path.isfile(dataset + '.zip'):
urllib.request.urlretrieve('http://files.hearthscry.com/collectobot/' + dataset + '.zip', dataset + '.zip')
with zipfile.ZipFile(dataset + '.zip', 'r') as z:
with z.open(dataset + '.json') as f:
data = json.loads(f.read().decode('utf-8'))
turns = np.array([max([0] + [card['turn'] for card in game['card_history']]) for game in data['games']])
return turns
turns = fetch_empirical_game_turns()
turn_hist = np.bincount(turns[turns > 0])
turn_survival = np.cumsum(turn_hist[::-1])[::-1]
turn_survival = turn_survival / np.max(turn_survival)
# Game length distribution.
trace = go.Scatter(
y=turn_hist,
mode='markers'
)
layout = go.Layout(
xaxis=dict(title='turns'),
yaxis=dict(title='games')
)
fig = go.Figure(data=[trace], layout=layout)
py.iplot(fig)
# Game survival rate.
trace = go.Scatter(
y=turn_survival,
mode='markers'
)
layout = go.Layout(
xaxis=dict(title='turns'),
yaxis=dict(title='survival rate')
)
fig = go.Figure(data=[trace], layout=layout)
py.iplot(fig)
class Game:
def __init__(self, deck=None, hand=None, coin=None, turns_to_play=25, verbose=False):
self.turn = 0
self.deck = rand_deck() if deck is None else np.copy(np.array(deck))
self.coin = np.random.choice([True, False], 1)[0] if coin is None else coin
self.hand = np.copy(hand) if hand is not None else None
if self.hand is None:
self.hand = draw_hand(self.deck, self.coin)
mulligan(self.deck, self.hand)
self.turns_to_play = turns_to_play
self.mana_wasted = np.zeros(self.turns_to_play + 1, dtype=np.int32)
self.num_plays = np.zeros(self.turns_to_play + 1, dtype=np.int32)
self.verbose = verbose
def play_turn(self):
self.turn += 1
draw_card(self.deck, self.hand)
play, self.mana_wasted[self.turn], self.num_plays[self.turn], used_coin = \
plan_play(self.hand, mana=min(MAX_MANA, self.turn), coin=self.coin)
if self.verbose:
print('Turn: {turn}, Hand: {hand}, Play: {play}{coin}, Mana wasted: {mw}, Number of plays: {np}'.format(
turn=self.turn,
hand=self.hand[self.hand != NOT_A_CARD],
play=self.hand[play[play != -1]],
coin=' + coin' if used_coin else '',
mw=self.mana_wasted[self.turn],
np=self.num_plays[self.turn]))
if used_coin:
self.coin = False
self.hand[play[play != -1]] = NOT_A_CARD
def weighted_mana_wasted(self):
# We're going to compute the total mana wasted in a turn as the mana
# that was wasted that turn, plus any many that was wasted in previous
# turns. That way, the mana wasted represents the total amount of mana
# we missed out on spending at any given turn.
mana_wasted = np.copy(self.mana_wasted[1:self.turn + 1])
mana_wasted[1:] = mana_wasted[1:] + np.cumsum(mana_wasted)[:-1]
# Use the empirical turn survival as a way to weight the mana wasted.
weights = turn_survival[1:self.turn + 1]
weights = weights / np.sum(weights)
return np.dot(weights, mana_wasted)
def weighted_num_plays(self):
# The value of the number of plays increases logarithmically,
# hence we take the mean across the log num_plays before converting
# back to a count with exp.
num_plays = np.log(self.num_plays[1:self.turn + 1])
# Use the empirical turn survival as a way to weight the number of plays.
weights = turn_survival[1:self.turn + 1]
weights = weights / np.sum(weights)
return np.exp(np.dot(weights, num_plays))
def cost(self):
# The cost of this game is the (weighted) average of the mana we missed out
# on spending this game, divided by the log of the number of plays we can
# make this and next turn. We use the log of the number of plays because the
# value of an additional play decreases rapidly with the number of plays.
return self.weighted_mana_wasted() / (1. + np.log(self.weighted_num_plays()))
def play(self):
for turn in range(self.turns_to_play):
if ~np.any(self.deck):
break
self.play_turn()
return self.cost()
deck = [0, 0, 6, 6, 5, 4, 3, 3, 2, 1, 0]
g = Game(deck=deck, verbose=True)
g.play();
Turn: 1, Hand: [2 3 6 5 2], Play: [2] + coin, Mana wasted: 0, Number of plays: 4 Turn: 2, Hand: [9 3 6 5 2], Play: [2], Mana wasted: 0, Number of plays: 1 Turn: 3, Hand: [9 3 6 5 5], Play: [3], Mana wasted: 0, Number of plays: 1 Turn: 4, Hand: [9 4 6 5 5], Play: [4], Mana wasted: 0, Number of plays: 2 Turn: 5, Hand: [9 3 6 5 5], Play: [5], Mana wasted: 0, Number of plays: 4 Turn: 6, Hand: [9 3 6 4 5], Play: [6], Mana wasted: 0, Number of plays: 1 Turn: 7, Hand: [9 3 2 4 5], Play: [3 4], Mana wasted: 0, Number of plays: 4 Turn: 8, Hand: [9 7 2 5], Play: [7], Mana wasted: 1, Number of plays: 4 Turn: 9, Hand: [9 4 2 5], Play: [9], Mana wasted: 0, Number of plays: 4 Turn: 10, Hand: [2 4 2 5], Play: [2 2 5], Mana wasted: 1, Number of plays: 4 Turn: 11, Hand: [6 4], Play: [6 4], Mana wasted: 0, Number of plays: 1 Turn: 12, Hand: [8], Play: [8], Mana wasted: 2, Number of plays: 1 Turn: 13, Hand: [3], Play: [3], Mana wasted: 7, Number of plays: 1 Turn: 14, Hand: [6], Play: [6], Mana wasted: 4, Number of plays: 1 Turn: 15, Hand: [5], Play: [5], Mana wasted: 5, Number of plays: 1 Turn: 16, Hand: [5], Play: [5], Mana wasted: 5, Number of plays: 1 Turn: 17, Hand: [3], Play: [3], Mana wasted: 7, Number of plays: 1 Turn: 18, Hand: [2], Play: [2], Mana wasted: 8, Number of plays: 1 Turn: 19, Hand: [2], Play: [2], Mana wasted: 8, Number of plays: 1 Turn: 20, Hand: [7], Play: [7], Mana wasted: 3, Number of plays: 1 Turn: 21, Hand: [4], Play: [4], Mana wasted: 6, Number of plays: 1 Turn: 22, Hand: [3], Play: [3], Mana wasted: 7, Number of plays: 1 Turn: 23, Hand: [3], Play: [3], Mana wasted: 7, Number of plays: 1 Turn: 24, Hand: [7], Play: [7], Mana wasted: 3, Number of plays: 1 Turn: 25, Hand: [8], Play: [8], Mana wasted: 2, Number of plays: 1
if 'grid' not in locals():
best_cost = np.inf
if os.path.isfile('decks.msg'):
grid = pd.read_msgpack('decks.msg')
else:
grid = {}
m, n = 6, 3
height = np.insert(np.linspace(1, 12, m), 0, 0)
loc = np.linspace(1, 3, n)
decks = set()
for hat in itertools.product(*([height] * 5 + [loc] * 3)):
hat = list(hat)
hat[-2] += hat[-3]
hat[-1] += hat[-2]
decks.add(tuple(hat_to_deck(hat)))
for deck in tqdm_notebook(decks, unit='deck'):
num_games = 128
if deck not in grid or grid[deck]['num_games'] < num_games:
games = [Game(deck=deck) for game in range(num_games)]
grid[deck] = {
'cost': np.median([game.play() for game in games]),
'mana_wasted': np.median([game.weighted_mana_wasted() for game in games]),
'num_plays': np.median([game.weighted_num_plays() for game in games]),
'num_games': num_games
}
pd.to_msgpack('decks.msg', grid)
def pareto_front(x, y, items):
idx = np.argsort(x)
x, y = np.array(x)[idx], np.array(y)[idx]
front = [0]
for i in range(1, len(x)):
if y[i] >= y[front[-1]]:
front.append(i)
front = idx[front]
items = [items[i] for i in front]
return items
for _ in range(3):
decks, mana_wasted, num_plays = zip(*[
(deck, grid[deck]['num_plays'], grid[deck]['mana_wasted'])
for deck in grid.keys()])
decks_pareto_front = pareto_front(num_plays, mana_wasted, decks)
for deck in tqdm_notebook(decks, unit='deck'):
num_games = 1024
if deck not in grid or grid[deck]['num_games'] >= num_games:
continue
close_to_pareto = False
for optimal_deck in decks_pareto_front:
if (grid[deck]['num_plays'] > 0.95 * grid[optimal_deck]['num_plays']) and \
(grid[deck]['mana_wasted'] < 1.05 * grid[optimal_deck]['mana_wasted']):
close_to_pareto = True
break
if close_to_pareto:
games = [Game(deck=deck) for game in range(num_games)]
grid[deck] = {
'cost': np.median([game.play() for game in games]),
'mana_wasted': np.median([game.weighted_mana_wasted() for game in games]),
'num_plays': np.median([game.weighted_num_plays() for game in games]),
'num_games': num_games
}
pd.to_msgpack('decks.msg', grid)
# Plot the pareto front of all simulated decks.
decks, mana_wasted, num_plays = zip(*[
(deck, grid[deck]['num_plays'], grid[deck]['mana_wasted'])
for deck in grid.keys()])
decks_pareto_front = pareto_front(num_plays, mana_wasted, decks)
if True:
trace_all = go.Histogram2d(
opacity=0.8,
x=[np.log10(grid[deck]['num_plays']) for deck in decks if deck not in decks_pareto_front],
y=[grid[deck]['mana_wasted'] for deck in decks if deck not in decks_pareto_front],
autobinx=False,
xbins=dict(start=np.log10(1), end=np.log10(11), size=0.0025),
autobiny=False,
ybins=dict(start=2, end=10, size=0.025),
hoverinfo='none',
zsmooth='best',
showscale=False,
colorscale='Portland',
xaxis='x',
yaxis='y'
)
else:
trace_all = go.Scatter(
opacity=0.5,
x=[grid[deck]['num_plays'] for deck in decks if deck not in decks_pareto_front],
y=[grid[deck]['mana_wasted'] for deck in decks if deck not in decks_pareto_front],
text=['Num plays: {num_plays}<br>Mana wasted: {mana_wasted}<br>Cost: {cost}<br>Games simulated: {num_games}<br>Deck: {deck}'.format(
mana_wasted=grid[deck]['mana_wasted'],
num_plays=grid[deck]['num_plays'],
cost=grid[deck]['cost'],
num_games=grid[deck]['num_games'],
deck=list(deck)) for deck in decks if deck not in decks_pareto_front],
marker=dict(color='rgba(0, 0, 0, .08)'),
mode='markers',
hoverinfo='none',
xaxis='x',
yaxis='y'
)
trace = go.Scatter(
x=[grid[deck]['num_plays'] for deck in decks_pareto_front],
y=[grid[deck]['mana_wasted'] for deck in decks_pareto_front],
text=['Num plays: {num_plays}<br>Mana wasted: {mana_wasted}<br>Cost: {cost}<br>Games simulated: {num_games}<br>Deck: {deck}'.format(
mana_wasted=grid[deck]['mana_wasted'],
num_plays=grid[deck]['num_plays'],
cost=grid[deck]['cost'],
num_games=grid[deck]['num_games'],
deck=list(deck)) for deck in decks_pareto_front],
marker=dict(colorscale='Hot', cmin=1.5, cmax=2, showscale=True, color=[grid[deck]['cost'] for deck in decks_pareto_front]),
mode='markers',
hoverinfo='text',
xaxis='x2',
yaxis='y'
)
layout = go.Layout(
showlegend=False,
title='Pareto front of Hearthstone mana curves',
xaxis2=dict(
range=[np.log10(1), np.log10(11)],
type='log',
title='Number of plays this & next turn<br><i style="font-size:0.8em;opacity:0.6">(The the number of plays with minimal mana loss you can expect<br>to choose from at any given turn for that turn and the turn after)</i>',
side='bottom',
overlaying='x'),
xaxis=dict(
range=[np.log10(1), np.log10(11)],
showgrid=False,
zeroline=False,
showline=False,
autotick=True,
ticks='',
showticklabels=False),
yaxis=dict(range=[2, 10], title='Cumulative mana wasted<br><i style="font-size:0.8em;opacity:0.6">(The total amount of mana you can expect<br>to have wasted in a game)</i>')
)
fig = go.Figure(data=[trace_all, trace], layout=layout)
py.iplot(fig)
def plot_deck(deck, simulations=1000):
games = [Game(deck=deck) for i in range(simulations)]
for game in games:
game.play()
mana_wasted = np.array([game.weighted_mana_wasted() for game in games])
num_plays = np.array([game.weighted_num_plays() for game in games])
trace1 = go.Scatter(
y=np.sort(mana_wasted),
mode='lines',
name='Cumulative mana wasted'
)
trace2 = go.Scatter(
y=np.sort(num_plays),
mode='lines',
name='Number of plays this & next turn',
yaxis='y2'
)
layout = go.Layout(
title='Mana curve = {deck}'.format(deck=deck),
xaxis=dict(title='simulated game'),
yaxis=dict(title='cumulative mana wasted<br><i style="font-size:0.8em;opacity:0.6">(The total amount of mana you can expect<br>to have wasted in a game)</i>', range=[2, 7]),
yaxis2=dict(title='number of plays this & next turn<br><i style="font-size:0.8em;opacity:0.6">(The the number of plays with minimal mana loss you can expect<br>to choose from at any given turn for that turn and the turn after)</i>', overlaying='y', side='right', range=[1, 6]),
legend=dict(x=0.01, y=1)
)
fig = go.Figure(data=[trace1, trace2], layout=layout)
py.iplot(fig)
deck = np.array([0, 0, 4, 5, 5, 4, 4, 3, 2, 2, 1])
plot_deck(deck)
deck = np.array([0, 0, 2, 8, 5, 4, 4, 3, 2, 1, 1])
plot_deck(deck)